<template>
    <view
        v-if="show"
        ref="lrlBubble"
        :id="onlyId"
        class="lrl-bubble"
        :style="[positionStyle]"
        @click="stopPropagation"
        @touchstart="stopPropagation"
        @touchmove="stopPropagation"
    >
        <view class="lrl-bubble-container" :style="[boxStyle]">
            <view
                v-for="(item, index) in list"
                :key="index"
                class="lrl-bubble-container-cell"
                :class="{ 'lrl-bubble-container-cell--border': !!list[index + 1] }"
                :style="[cellStyle, { borderColor: lineColor }]"
                @click="tapItem(item, index)"
            >
                <up-icon
                    v-if="item[mapKey.icon]"
                    :class="['lrl-bubble-container-cell-icon', customIconPrefix, item[mapKey.icon]]"
                    :name="item[mapKey.icon]"
                    :size="sizeUnit(item.iconSize) || sizeUnit(iconSize)"
                    :color="item.iconColor || iconColor"
                ></up-icon>
                <image
                    v-else-if="item[mapKey.image]"
                    :src="item[mapKey.image]"
                    class="lrl-bubble-container-cell-image"
                    :style="[
                        {
                            width: sizeUnit(item.iconSize) || sizeUnit(iconSize),
                            height: sizeUnit(item.iconSize) || sizeUnit(iconSize),
                            borderRadius: sizeUnit(imageBorderRadius)
                        }
                    ]"
                ></image>
                <text
                    class="lrl-bubble-container-cell-text"
                    :style="[
                        {
                            fontSize: sizeUnit(item.fontSize) || sizeUnit(fontSize),
                            color: item.fontColor || fontColor
                        }
                    ]"
                >
                    {{ item[mapKey.text] }}
                </text>
            </view>
        </view>
    </view>
</template>

<script>
import { deepClone, timeWait, getRect, getRandomLetter, isValue } from '../../libs/util/tools.js';
import mixin from '../../libs/mixin/mixin.js';
// #ifdef APP-NVUE
const animation = uni.requireNativePlugin('animation');
// #endif
/**
 * BubbleMenu 自动定位弹出气泡菜单
 * @description [app|app-nvue|h5|mp-weixin] 自动上下左右定位弹出气泡菜单，一般用于点击或长按弹出跟随菜单
 * @author liruilin
 * @property {String}             position = [client|place] 定位模式，详见源码注释
 *  @value client 指定元素定位，在调用 open 时需要传入 clientId 和 rangeId，详见源码注释
 *  @value place  指定坐标位置，在调用 open 时需要传入 rangeId 和触摸事件的 pageX 和 pageY，详见源码注释
 * @property {String}             positionDotSize   指定x y位置时模拟点的大小，默认单位rpx，默认10
 * @property {String}             customIconPrefix  自定义字体图标库class，默认iconfont，nvue不支持
 * @property {String|Number}      fontSize          默认菜单文字大小，可在 list 单独配置 fontSize，默认单位rpx，默认32，
 * @property {String}             fontColor         默认菜单文字颜色，可在 list 单独配置 fontColor，默认#000
 * @property {String|Number}      iconSize          默认菜单图标大小，image图标时为高宽，可在 list 单独配置 iconSize，默认单位rpx，默认32
 * @property {String}             iconColor         默认菜单图标颜色，可在 list 单独配置 iconColor，默认#000
 * @property {String}             bgColor           背景颜色，默认#fff
 * @property {String|Number}      borderRadius      背景圆角，默认单位rpx，默认10
 * @property {String}             lineColor         选项底部线条颜色，默认#E0E0E0
 * @property {String|Number}      imageBorderRadius 菜单图片圆角，默认单位rpx，默认0
 * @property {String|Number}      minWidth          菜单最小宽度，默认单位rpx，默认100
 * @property {String|Number}      maxWidth          菜单最大宽度，默认单位rpx，默认空，最好由组件自动计算最大宽度为准。
 * @property {String|Number}      zIndex            菜单层级，默认10，nvue不支持，nvue将标签放置在最底部即为最高层级
 * @property {Object}             map               {text, icon, image} 数据key映射，text为显示文字映射，icon为图标icon class映射，image为图片图标映射
 * @property {Boolean}            clickClose = [true|false]        点击菜单是否关闭
 * @property {Boolean}            moveTransition = [true|false]    菜单未关闭状态下再次打开定位时是否启用动画过渡移动效果
 * @property {Boolean}            repeatOpenTransitionHide = [true|false]  在开启移动动画时，重新定位打开是否过渡一次隐藏，一般在选项有变化时设置为true，主要是解决选项切换导致的渲染闪烁问题。
 * @event {Function()}            change            菜单点击,第一个参数为被点击菜单数据，第二个为被点击菜单索引，可用作判断点击类型
 * @example <lrl-bubble-menu ref="lrlBubbleMenu"></lrl-bubble-menu>
 */
export default {
    name: 'lrl-bubble-menu',
    mixins: [mixin],
    emits: ["change"],
    props: {
        position: {
            type: String,
            default: 'client'
        },
        positionDotSize: {
            type: [String, Number],
            default: 10
        },
        customIconPrefix: {
            type: String,
            default: 'iconfont'
        },
        fontSize: {
            type: [String, Number],
            default: '32'
        },
        fontColor: {
            type: String,
            default: '#000'
        },
        iconSize: {
            type: [String, Number],
            default: 32
        },
        iconColor: {
            type: String,
            default: '#000'
        },
        bgColor: {
            type: String,
            default: '#FFFFFF'
        },
        borderRadius: {
            type: [String, Number],
            default: 10
        },
        lineColor: {
            type: String,
            default: '#E0E0E0'
        },
        imageBorderRadius: {
            type: [String, Number],
            default: 0
        },
        minWidth: {
            type: [String, Number],
            default: 100
        },
        maxWidth: {
            type: [String, Number],
            default: ''
        },
        zIndex: {
            type: [String, Number],
            default: 10
        },
        clickClose: {
            type: Boolean,
            default: true
        },
        moveTransition: {
            type: Boolean,
            default: false
        },
        repeatOpenTransitionHide: {
            type: Boolean,
            default: false
        },
        map: {
            type: Object,
            default: () => {
                return {
                    text: 'text',
                    icon: 'icon',
                    image: 'image'
                };
            }
        }
    },
    data() {
        return {
            // 因为是使用的dom查询，避免一个页面多次使用组件时保证唯一id
            onlyId: getRandomLetter(8),
            show: false,
            showHorn: false,
            opacity: false,
            clientId: '',
            list: [],
            range: {},
            client: {},
            bubble: {},
            top: '',
            left: '',
            calMaxWidth: '',
            pageX: 0,
            pageY: 0,
            animationStyles: {}
        };
    },
    created() {

    },
    computed: {
        /**
         * 合并映射key
         */
        mapKey() {
            return Object.assign(
                {
                    text: 'text',
                    icon: 'icon',
                    image: 'image'
                },
                this.map
            );
        },
        positionStyle() {
            const { zIndex, animationStyles } = this;

            let style = {
                // #ifndef APP-NVUE
                zIndex,
                // #endif
                ...animationStyles
            };

            return style;
        },
        boxStyle() {
            const { zIndex, bgColor, opacity, borderRadius } = this;
            let style = {
                backgroundColor: bgColor,
                borderRadius: this.sizeUnit(borderRadius),
                zIndex
            };
            return style;
        },
        cellStyle() {
            const [seUpMaxW, { calMaxWidth }] = [this.sizeRpx2px(this.maxWidth), this];
            return {
                minWidth: this.sizeUnit(this.minWidth),
                // 最大宽度由计算得出，组件设置的最大宽度超过能显示的最大宽度将不会生效，这里16 * 2是算的为了兼容nvue做的padding
                maxWidth: (seUpMaxW && seUpMaxW < calMaxWidth ? seUpMaxW : calMaxWidth) - uni.upx2px(16 * 2) + 'px'
            };
        }
    },
    methods: {
        reset() {
            this.left = '';
            this.top = '';
        },
        async open({
            // 被定位元素id
            clientId,
            // 定位可视范围元素id
            rangeId,
            // 被定位元素所属组件，一般不需要传入，在被查找的id不在当前使用组件时需要传入，app以及h5不传可以查找到子组件里面的id
            clientInRef = this.$parent,
            // 定位可视范围元素所属组件，一般不需要传入，在被查找的id不在当前使用组件时需要传入，app以及h5不传可以查找到子组件里面的id
            rangeInRef = this.$parent,
            /**
             * 菜单选项
             * [
             *    {text, icon: 图标, image: 图片图标, fontSize: 文字大小, iconSize: 图标大小，为image图标宽度, color: 文字颜色, iconColor: 图标颜色}
             * ]
             * 优先展示icon，无icon将以image为图标
             */
            list = [],
            /**
             * pageX、clientX等的区别
             * https://blog.csdn.net/qq_45467083/article/details/106366800
             */
            // 跟随手指定位模式的x轴
            pageX = 0,
            // 跟随手指定位模式的y轴
            pageY = 0,
            // 定位左右偏量阈值，可自定义往左或者往右的偏移量
            xAxisScale = 1,
            // 优先展示位置 [top | bottom]
            priorityPosition = 'top',
            // 可视区域左边安全距离 rpx
            windowLeft = 0,
            // 可视区域右边安全距离 rpx
            windowRight = 0,
            // 可视区域顶部安全距离 rpx
            windowTop = 0,
            // 可视区域底部安全距离 rpx
            windowBottom = 0
        } = {}) {
            const oldLeft = this.left;
            const oldTop = this.top;

            // 是否是未关闭再次重新定位打开
            let oldShow = this.show;
            // 重复打开执行关闭
            if (oldShow && clientId === this.clientId && this.position == 'client') {
                this.close();
                return;
            }

            // 未关闭菜单的情况再次打开，处理计算前的隐藏，只会有一种情况不需要隐藏：开启移动动画并且未开启移动隐藏
            if (oldShow && !(this.moveTransition && !this.repeatOpenTransitionHide)) {
                // 先按上一个事件的位置隐藏，如果oldShow为true，必定会有oldLeft 和 oldTop
                await this.bubbleAnimation(
                    {
                        opacity: 0,
                        transform: `translate(${oldLeft}px, ${oldTop}px)`
                        // 这里设置时间的目的是优化移动隐藏时间，太久了感官不太好
                    },
                    this.moveTransition && this.repeatOpenTransitionHide ? 100 : 60,
                    0
                );
            }

            // 选择性设计，浅拷贝在使用的时候可以动态修改属性
            this.list = list;
            // this.list = deepClone(list)

            this.clientId = clientId;
            this.show = true;
            this.range = {};
            this.client = {};
            this.bubble = {};
            this.windowLeft = uni.upx2px(windowLeft);
            this.windowRight = uni.upx2px(windowRight);
            this.windowTop = uni.upx2px(windowTop);
            this.windowBottom = uni.upx2px(windowBottom);
            this.xAxisScale = xAxisScale;
            this.pageX = pageX;
            this.pageY = pageY;

            this.$nextTick(async function () {
                // 可视区域信息
                this.range = await getRect('#' + rangeId, rangeInRef);
                // 定位基准元素信息
                if (this.position == 'client') {
                    // 跟随指定元素，获取元素高度宽度 left right
                    this.client = await getRect('#' + clientId, clientInRef);
                } else {
                    // 指定位置模拟出一个位置根据点，
                    let positionDotSize = this.sizeRpx2px(this.positionDotSize);
                    this.client = {
                        width: positionDotSize,
                        height: positionDotSize,
                        left: pageX - positionDotSize / 2,
                        right: pageX + positionDotSize / 2,
                        top: pageY - positionDotSize / 2,
                        bottom: pageY + positionDotSize / 2
                    };
                }
                // 计算最大宽度
                // this.calMaxWidth = this.range.width - (this.windowLeft + this.windowRight) - (uni.getSystemInfoSync().windowWidth - this.range.right)
                this.calMaxWidth = this.range.width - (this.windowLeft + this.windowRight);
                // 这里需要等待气泡菜单最终宽度生效。
                this.$nextTick(async () => {
                    // #ifdef APP-NVUE
                    await timeWait(10);
                    // #endif
                    // 获取气泡菜单信息
                    this.bubble = await getRect('#' + this.onlyId, this);
                    // console.log(JSON.stringify(this.range))
                    // console.log(JSON.stringify(this.client))
                    // console.log(JSON.stringify(this.bubble))
                    if (priorityPosition === 'top') {
                        this.topCompute();
                    } else {
                        this.bottomCompute();
                    }
                    this.leftCompute();

                    this.$nextTick(async () => {
                        const { top, left } = this;

                        // 开启移动动画处理
                        if (!(this.moveTransition && oldShow)) {
                            // 上一个事件未关闭的情况下再次打开菜单
                            if (oldShow) {
                                // 移动到当前事件定位位置
                                await this.bubbleAnimation(
                                    {
                                        opacity: 0,
                                        transform: `translate(${left}px, ${top}px)`
                                    },
                                    0,
                                    0
                                );
                            } else {
                                //
                                await this.bubbleAnimation(
                                    {
                                        transform: `translate(${left}px, ${top}px)`
                                    },
                                    0,
                                    0
                                );
                            }
                        }
                        this.bubbleAnimation(
                            {
                                opacity: 1,
                                transform: `translate(${left}px, ${top}px)`
                            },
                            100,
                            0
                        );
                    });
                });
            });
        },
        // 气泡菜单动画
        bubbleAnimation(styles, duration = 200, delay = 0) {
            return new Promise(async (resolve) => {
                // #ifdef APP-NVUE
                // nvue使用weex动画
                animation.transition(
                    this.$refs['lrlBubble'],
                    {
                        styles,
                        duration, //ms
                        timingFunction: 'ease-in',
                        delay //ms
                    },
                    () => {
                        resolve();
                    }
                );
                // #endif

                // #ifndef APP-NVUE
                styles['transition-duration'] = isValue(duration) ? `${duration / 1000}s` : '0.3s';
                this.animationStyles = { ...this.animationStyles, ...styles };
                await timeWait(duration);
                resolve();
                // #endif
            });
        },
        // 以顶部优先定位计算
        topCompute(already = false) {
            const { range, client, bubble, windowTop } = this;
            // 顶部可显示区域高度
            let contentTopHeight = client.top - range.top;
            // 如果可显示区域高度 + 设置顶部安全高度 大于气泡菜单高度，那么就直接以顶部展示
            if (contentTopHeight + windowTop > bubble.height) {
                this.top = client.top - bubble.height + windowTop;
                return true;
            } else if (!already) {
                // 否则计算以底部定位
                if (!this.bottomCompute(true)) {
                    // 如果以底部定位计算也未能显示就已 client 可视区域的top为气泡菜单的top展示即可
                    this.top = range.top + windowTop;
                    return true;
                }
            }
            return false;
        },
        // 以底部优先定位计算
        bottomCompute(already = false) {
            const { range, client, bubble, windowBottom, windowTop } = this;
            //
            if (range.height + range.top - (client.height + client.top) + windowBottom >= bubble.height) {
                this.top = client.top + client.height;
                return true;
            } else if (!already) {
                if (!this.topCompute(true)) {
                    // 如果以高度计算未能显示就已可视区域最底部对齐，计算方式为 可视区域高度 + 可视区域top - 气泡菜单的高度 - 底部安全区
                    this.top = range.top + range.height - bubble.height - windowBottom;
                    return true;
                }
            }
            return false;
        },
        // 计算左右位置，
        leftCompute() {
            const { range, client, bubble, windowLeft, windowRight, xAxisScale } = this;
            // 先计算在无任何限制的情况下应有的left值
            let initLeft = client.left + client.width / 2 - bubble.width / 2;
            // x轴偏移比例计算
            initLeft = initLeft * xAxisScale;
            // 能设置最大的left值，可视范围left + 可视范围width - 气泡菜单的宽度，即为最大left
            let maxLeft = range.left + range.width - bubble.width - windowRight;
            // 能设置的最小left值，可视范围left + 设置的安全left值
            let minLeft = range.left + windowLeft;
            // 如最初计算left小于可视范围left说明left过于靠左，直接设置最小left
            if (initLeft < minLeft) {
                this.left = minLeft;
            } else {
                // 最初计算出气泡菜单left如果大于最大left值
                this.left = initLeft > maxLeft ? maxLeft : initLeft;
            }
        },
        // 隐藏菜单
        async close() {
            if (this.show) {
                let styles = {
                    opacity: 0,
                    transform: `translate(${this.left}px, ${this.top}px)`
                };
                this.bubbleAnimation(styles, 200, 0).then(() => {
                    this.show = false;
                });
            }
        },
        // 点击选项
        tapItem(item, index) {
            this.$emit('change', Object.assign({}, item), index)
            if (this.clickClose) this.close();
        },
        // 取消冒泡
        stopPropagation(e) {
            // #ifndef APP-NVUE || MP
            e.stopPropagation && typeof e.stopPropagation === 'function' && e.stopPropagation();
            // #endif
        }
    }
};
</script>

<style lang="scss" scoped>
$lrl-bubble-padding-left: 16rpx;
$lrl-bubble-padding-right: 16rpx;
$lrl-bubble-padding-top: 16rpx;
$lrl-bubble-padding-bottom: 16rpx;
$lrl-bubble-padding-bottom: 16rpx;
$lrl-bubble-container-box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.15);
$lrl-bubble-container-border: 1px solid rgba(0, 0, 0, 0);
$lrl-bubble-container-cell-padding-left: 20rpx;
$lrl-bubble-container-cell-padding-right: 20rpx;
$lrl-bubble-container-cell-padding-top: 15rpx;
$lrl-bubble-container-cell-padding-bottom: 15rpx;
$lrl-bubble-container-cell-border-bottom-width: 1px;
$lrl-bubble-container-cell-icon-margin-right: 10rpx;

.lrl-bubble {
    position: fixed;
    padding-left: $lrl-bubble-padding-left;
    padding-right: $lrl-bubble-padding-right;
    padding-top: $lrl-bubble-padding-top;
    padding-bottom: $lrl-bubble-padding-bottom;
    top: 0;
    left: 0;
    transition-property: transform, opacity;
    /* #ifdef APP-NVUE */
    opacity: 0;
    /* #endif */
    opacity: 0;
    &-container {
        position: relative;
        display: flex;
        flex-direction: column;
        justify-content: flex-start;
        box-shadow: $lrl-bubble-container-box-shadow;
        /* #ifdef APP-NVUE */
        // DOTO 2022/6/24 nvue不知是什么情况不设置边框阴影显示不出来  // 兼容 nvue
        border: $lrl-bubble-container-border;
        /* #endif */
        /* #ifndef APP-NVUE */
        width: max-content;
        overflow: hidden;
        /* #endif */

        &-cell {
            padding-left: $lrl-bubble-container-cell-padding-left;
            padding-right: $lrl-bubble-container-cell-padding-right;
            padding-top: $lrl-bubble-container-cell-padding-top;
            padding-bottom: $lrl-bubble-container-cell-padding-bottom;
            display: flex;
            flex-direction: row;
            align-items: center;
            /* #ifndef APP-NVUE */
            &:active {
                background-color: rgba(0, 0, 0, 0.1);
            }
            /* #endif */

            &--border {
                border-bottom-width: $lrl-bubble-container-cell-border-bottom-width;
                border-bottom-style: solid;
            }

            &-text {
                flex: 1;
                /* #ifndef APP-NVUE */
                display: inline-block;
                /* #endif */
                word-break: break-all;
                white-space: pre-wrap;
            }

            &-icon {
                margin-right: $lrl-bubble-container-cell-icon-margin-right;
                flex-shrink: 0;
            }

            &-image {
                margin-right: $lrl-bubble-container-cell-icon-margin-right;
                flex-shrink: 0;
            }
        }
    }
}
</style>
